"use client"; /** * /w/:workspace/settings/automations/:id — DETAIL + EDITOR + RUN HISTORY. * * - header: name + description (PATCH /automations/:id), status controls per * the status machine (DRAFT→ACTIVE; ACTIVE↔PAUSED; ACTIVE/PAUSED→ARCHIVED; * ARCHIVED→ACTIVE), "Run now" (POST /automations/:id/run → 202 {runId}) * and Delete (DELETE → archives). * - TRIGGERS (1..3): add/remove + per-type config; a WEBHOOK trigger shows * its URL + "Regenerate secret" (POST …/webhook/regenerate, one-time * plaintext reveal). * - CONDITION (0..1 tree): "No condition" toggle + single leaf-predicate row * (or a raw-JSON editor for the full tree). * - ACTIONS (1..50, ordered): add/reorder/delete + per-type config + * per-action retries (1..8) and timeoutMs (1s..300s). * - SAVE: replace-set sub-resource PUTs (…/triggers, …/conditions, * …/actions) + the name/description PATCH. 422 path-located details are * surfaced inline. * - RUN HISTORY: GET /automations/:id/runs (limit 50, `{ data, nextCursor }`) * with expandable logs + retry on FAILED/DEAD_LETTER runs. */ import { useEffect, useMemo, useRef, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, Check, Copy, Loader2, Play, Save, Trash2 } from "lucide-react"; import { toast } from "@/stores/ui-store"; import { apiFetch, ApiError } from "@/lib/api"; import { useWorkspace } from "@/hooks/use-workspace"; import { SettingsShell, Section } from "@/components/settings/settings-shell"; import { AUTOMATION_STATUS_TRANSITIONS, conditionDraftFromWire, conditionPayload, conditionSummary, emptyCondition, statusCls, triggerDraftFromWire, triggerPayload, actionDraftFromWire, actionPayload, runStatusLabel, type ActionDraft, type AutomationDetail, type AutomationStatus, type ConditionDraft, type TriggerDraft } from "@/components/automations/lib"; import { TriggerEditor } from "@/components/automations/trigger-editor"; import { ConditionEditor, validateConditionJson } from "@/components/automations/condition-editor"; import { ActionEditor } from "@/components/automations/action-editor"; import { RunHistory } from "@/components/automations/run-history"; interface PutTriggersResult { automationId: string; triggers: { type: string; config: Record }[]; webhooks: { webhookId: string; secret: string; url: string }[]; } export default function AutomationDetailPage() { const params = useParams<{ workspace: string; id: string }>(); const slugOrId = params?.workspace ?? ""; const automationId = params?.id ?? ""; const router = useRouter(); const qc = useQueryClient(); const { data: ws, isLoading } = useWorkspace(slugOrId); const detail = useQuery({ queryKey: ["automations", automationId], queryFn: async (): Promise => apiFetch(`/automations/${automationId}`), enabled: !!automationId }); const a = detail.data; /* --------------------------------------------------------- local state */ const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [triggers, setTriggers] = useState([]); const [condition, setCondition] = useState(emptyCondition()); const [actions, setActions] = useState([]); /** Serialized baseline of the last-saved (or loaded) draft; dirty = diff. */ const [baseline, setBaseline] = useState(null); const [sectionError, setSectionError] = useState(null); const [secret, setSecret] = useState<{ url: string; secret: string } | null>(null); const [copied, setCopied] = useState(false); /** Id whose detail the editor is currently hydrated from. */ const initRef = useRef(null); // Initialize the editor when the detail loads for a NEW automation. A // refetch of the SAME id (background poll, status change, post-save // invalidate) must NOT re-hydrate — it would clobber in-progress edits. useEffect(() => { if (!a) return; if (initRef.current === a.id) return; initRef.current = a.id; const t = a.triggers.map((x) => triggerDraftFromWire(x)); const c = conditionDraftFromWire(a.condition); const acts = a.actions.map((x) => actionDraftFromWire(x)); setName(a.name); setDescription(a.description ?? ""); setTriggers(t); setCondition(c); setActions(acts); setSectionError(null); setSecret(null); // Baseline must use the values being ADOPTED (the closure's name/description // still hold the previous render's state). setBaseline(localSignature(a.name, a.description ?? "", t, c, acts)); }, [a]); const dirty = useMemo( () => (baseline !== null ? localSignature(name, description, triggers, condition, actions) !== baseline : false), [baseline, name, description, triggers, condition, actions] ); const metaKey = ["automations", automationId] as const; /* ------------------------------------------------------------- mutations */ const patchMeta = useMutation({ mutationFn: () => apiFetch(`/automations/${automationId}`, { method: "PATCH", body: { name: name.trim(), description: description.trim() || null } }) }); const putTriggers = useMutation({ mutationFn: () => apiFetch(`/automations/${automationId}/triggers`, { method: "PUT", body: { triggers: triggers.map((t) => triggerPayload(t)) } }), onSuccess: (res) => { if (res.webhooks.length > 0) { const wh = res.webhooks[0]!; setSecret({ url: wh.url, secret: wh.secret }); } // Adopt the server's sanitized trigger set (canonical webhook url/order). setTriggers(res.triggers.map((t) => triggerDraftFromWire(t))); } }); const putConditions = useMutation({ mutationFn: () => { if (condition.hasCondition && condition.json.trim() && validateConditionJson(condition.json)) { throw new ApiError("INVALID_CONDITION", 422, "Condition JSON does not match the condition schema."); } let body: { condition: unknown }; try { body = conditionPayload(condition); } catch (e) { throw new ApiError("INVALID_CONDITION", 422, `Invalid condition JSON: ${(e as Error).message}`); } return apiFetch(`/automations/${automationId}/conditions`, { method: "PUT", body }); } }); const putActions = useMutation({ mutationFn: () => apiFetch(`/automations/${automationId}/actions`, { method: "PUT", body: { actions: actions.map((x) => actionPayload(x)) } }), onSuccess: (res) => { // Re-adopt the normalized action list (defaults + clamped knobs). setActions(res.actions.map((x) => actionDraftFromWire(x))); } }); const save = () => { setSectionError(null); void (async () => { try { // Capture the resolved results directly (the per-render mutation // object's `.data` would be stale inside this closure). const [trigRes, , actRes] = await Promise.all([ triggers.length > 0 ? putTriggers.mutateAsync() : Promise.resolve(null as PutTriggersResult | null), putConditions.mutateAsync(), actions.length > 0 ? putActions.mutateAsync() : Promise.resolve(null as AutomationDetail | null), patchMeta.mutateAsync() ]); const nextTriggers = trigRes ? trigRes.triggers.map((t) => triggerDraftFromWire(t)) : triggers; const nextActions = actRes ? actRes.actions.map((x) => actionDraftFromWire(x)) : actions; // Baseline = the state being adopted (server-normalized where a PUT // ran), so the dirty check reads clean right after save. setBaseline(localSignature(name, description, nextTriggers, condition, nextActions)); if (trigRes) setTriggers(nextTriggers); if (actRes) setActions(nextActions); toast("success", "Automation saved"); qc.invalidateQueries({ queryKey: metaKey }); // refresh status / latestRun } catch (err) { const msg = err instanceof Error ? err.message : "Save failed"; setSectionError(msg); toast("error", "Save failed", msg); } })(); }; const setStatus = useMutation({ mutationFn: (status: string) => apiFetch(`/automations/${automationId}/status`, { method: "POST", body: { status } }), onSuccess: (res) => { qc.setQueryData(metaKey, res); toast("success", `Automation ${res.status.toLowerCase()}`); }, onError: (err) => { if (err instanceof ApiError && err.status === 409) toast("error", "Cannot change status", err.message); else toast("error", "Status change failed", err instanceof Error ? err.message : undefined); } }); const runNow = useMutation({ mutationFn: () => apiFetch<{ runId: string }>(`/automations/${automationId}/run`, { method: "POST", body: {} }), onSuccess: (res) => { toast("success", "Run triggered", res.runId ? `run ${res.runId.slice(0, 8)}…` : undefined); qc.invalidateQueries({ queryKey: ["automations", automationId, "runs"] }); qc.invalidateQueries({ queryKey: metaKey }); }, onError: (err) => toast("error", "Could not run", err instanceof Error ? err.message : undefined) }); const regenWebhook = useMutation({ mutationFn: () => apiFetch<{ webhookId: string; secret: string; url: string }>(`/automations/${automationId}/webhook/regenerate`, { method: "POST" }), onSuccess: (res) => { setSecret({ url: res.url, secret: res.secret }); toast("success", "Webhook secret regenerated", "Copy the new secret now — it won't be shown again."); }, onError: (err) => toast("error", "Regenerate failed", err instanceof Error ? err.message : undefined) }); const remove = useMutation({ mutationFn: () => apiFetch(`/automations/${automationId}`, { method: "DELETE" }), onSuccess: () => { toast("success", "Automation deleted (archived)"); qc.invalidateQueries({ queryKey: ["workspaces", ws?.id ?? "", "automations"] }); router.push(`/w/${slugOrId}/settings/automations`); }, onError: (err) => toast("error", "Delete failed", err instanceof Error ? err.message : undefined) }); /* --------------------------------------------------------------- render */ const status = (a?.status ?? "DRAFT") as AutomationStatus; const transitions = AUTOMATION_STATUS_TRANSITIONS[status] ?? []; const saving = patchMeta.isPending || putTriggers.isPending || putConditions.isPending || putActions.isPending; return ( {isLoading || !ws ? (

Loading…

) : detail.isLoading || !a ? (

Loading…

) : detail.isError ? (

Could not load automation: {(detail.error as Error).message}

) : (
{/* header */}
{a.status} {transitions.map((t) => ( ))}
{/* name + description */}
setName(e.target.value)} aria-label="Automation name" />